<?php
namespace Lib\Http;

use Core\HHO;

/**
 * HTTP 请求控制器，特性：
 *
 * - 支持表单提交，仅需向  self::get() 或 self::post() 传递一个 Form 对象即可。
 * - 支持大文件上传，大文件上传应该选用 self::OPT_MODE_SOCKET 方式进行请求。
 * - 支持大文件下载，仅需指定一个文件名，下载数据将直接写入此文件，以节约内存资源。
 * - 支持 https 请求，但需要 PHP 的 curl 扩展支持。
 * - 支持自动重定向，且可设置自动重定向的次数。
 * - 支持 BASIC 用户身份认证。
 * - 允许设置连接超时时间，如果选用 self::OPT_MODE_SOCKET 方式进行请求，还可设置响应数据读取的超时时间。
 * - 允许自定义 HTTP 消息头信息。
 * - 支持 Cookie 和会话，仅需向 self::get() 或 self::post() 传递一个 Cookie 对象即可。
 *
 * @author SUSISOY
 *
 */
class Request {
	const HTTP_METHOD_GET = 'GET';
	const HTTP_METHOD_POST = 'POST';
	const OPT_TIMEOUT = 1;
	const OPT_READ_TIMEOUT = 2;
	const OPT_REDIRECT = 3; // 允许自动重定向的次数，0 则禁止自动重定向，默认为 8 次。
	const OPT_MODE = 4;
	const OPT_IGNORE_FAIL = 5; // 是否忽略 400 及以上的响应，默认不忽略。
	const OPT_MODE_DEFAULT = 400;
	const OPT_MODE_STREAM = 401;
	const OPT_MODE_CURL = 402;
	const OPT_MODE_SOCKET = 403;
	protected $header = array();
	/**
	 * 当前 HTTP 请求的相关配置
	 * - self::OPT_TIMEOUT：服务器连接超时时长，默认 8 秒
	 * - self::OPT_READ_TIMEOUT：读取服务器响应内容超时时长，默认 16 秒
	 * - self::OPT_REDIRECT：允许自动重定向的深度
	 *
	 * @var array
	 */
	protected $option = array(self::OPT_TIMEOUT => 8, self::OPT_READ_TIMEOUT => 16, self::OPT_REDIRECT => 8, self::OPT_MODE => self::OPT_MODE_DEFAULT, self::OPT_IGNORE_FAIL => FALSE);
	
	/**
	 * 用于 HTTP 认证的用户名和密码
	 *
	 * @var array
	 */
	private $auth = array();

	public function __construct() {
		$this->header['Accept'] = '*/*';
		$this->header['Accept-Encoding'] = 'gzip,deflate';
		$this->header['Accept-Language'] = 'zh-cn,zh;q=0.5';
		$this->header['Accept-Charset'] = 'GB2312,UTF-8;q=0.7,*;q=0.7';
		$this->header['User-Agent'] = 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)';
		$this->header['Connection'] = 'Close';
	}

	/**
	 * 设置或获得当前 HTTP 请求的相关配置参数值，返回值如下：
	 * - 设置成功返回 TRUE，设置失败返回 FALSE。
	 * - 获得成功返回指定配置参数的值，失败返回 NULL。
	 *
	 * @param int $name
	 * @param mixed $value
	 * @return mixed
	 */
	public function option($name, $value) {
		if (is_null($value)) {
			return isset($this->option[$name]) ? $this->option[$name] : NULL;
		}
		
		switch ($name) {
			case self::OPT_TIMEOUT :
			case self::OPT_READ_TIMEOUT :
			case self::OPT_REDIRECT :
				if ($value < 0) {
					return FALSE;
				}
				
				break;
			case self::OPT_MODE :
				if (!in_array($value, array(self::OPT_MODE_DEFAULT, self::OPT_MODE_CURL, self::OPT_MODE_SOCKET, self::OPT_MODE_STREAM))) {
					return FALSE;
				}
				
				break;
			case self::OPT_IGNORE_FAIL :
				$value = $value == TRUE;
				
				break;
			default :
				return FALSE;
		}
		
		$this->option[$name] = $value;
		
		return TRUE;
	}

	public function agent($content) {
		return $this->header('User-Agent', $content);
	}

	public function referer($url) {
		return $this->header('Referer', $url);
	}

	/**
	 * 添加或修改或删除一个 HTTP 请求消息头。
	 *
	 * @param string $name
	 * @param string $value
	 * @return boolean
	 */
	public function header($name, $value = NULL) {
		if (!$value) {
			$h = explode(':', $name);
			
			$name = isset($h[0]) ? $h[0] : '';
			$value = isset($h[1]) ? $h[1] : '';
		}
		
		$name = str_replace(' ', '-', ucwords(str_replace('-', ' ', strtolower(trim($name)))));
		if (!$name) {
			return FALSE;
		}
		
		$value = trim($value);
		if ($value) {
			$this->header[$name] = $value;
		} elseif (isset($this->header[$name])) {
			unset($this->header[$name]);
		}
		
		return TRUE;
	}

	private function auth($url) {
		$prefix = $url['host'] . ':' . $url['port'] . $url['path'];
		
		foreach ($this->auth as $k => $v) {
			if (substr($prefix, 0, strlen($k)) == $k) {
				return $v;
			}
		}
		
		return '';
	}

	private function url($url) {
		$url = parse_url(preg_match('/^https?:\/\/\w/i', $url) ? $url : 'http://' . $url);
		
		$url['scheme'] = strtolower($url['scheme']);
		
		$url['host'] = strtolower($url['host']);
		
		isset($url['port']) || $url['port'] = '';
		
		if (isset($url['path'])) {
			$url['path'] = str_replace('/./', '/', $url['path']);
			
			while (preg_match('/\/[\w\-]+\/\.\.\//', $url['path'])) {
				$url['path'] = preg_replace('/\/[\w\-]+\/\.\.\//', '/', $url['path']);
			}
			
			$url['path'] || $url['path'] = '/';
		} else {
			$url['path'] = '/';
		}
		
		if (isset($url['user']) && isset($url['pass'])) {
			$this->auth[$url['host'] . ':' . $url['port'] . $url['path']] = $url['user'] . ':' . $url['pass'];
			
			krsort($this->auth);
		}
		
		return $url;
	}

	private function _setcookie($host, $path, $Cookie, $Response) {
		if (is_object($Cookie) && is_object($Response)) {
			if (isset($Response->header['Set-Cookie'])) {
				foreach ($Response->header['Set-Cookie'] as $v) {
					$Cookie->setcookie($v, $path, $host);
				}
			}
			
			if (isset($Response->meta['Set-Cookie'])) {
				foreach ($Response->meta['Set-Cookie'] as $v) {
					$Cookie->setcookie($v, $path, $host);
				}
			}
		}
	}

	/**
	 * GET 请求一个指定的 URL，若请求失败则返回 FALSE，若服务器响应 400 及以上结果，且设置了忽略 400 及以上的响应，则返回 NULL，否则返回一个 Response 对象
	 *
	 * @param string $url
	 * @param Form $form
	 * @param Cookie $Cookie
	 * @param string $filename
	 * @return Response|FALSE
	 */
	public function get($url, $form = NULL, Cookie $Cookie = NULL, $filename = NULL) {
		return $this->request(self::HTTP_METHOD_GET, $url, $form, $Cookie, $filename);
	}

	/**
	 * POST 请求一个指定的 URL，返回值与 self::get() 的返回值相同。
	 *
	 * @param string $url
	 * @param Form $form
	 * @param Cookie $Cookie
	 * @param string $filename
	 * @return Response|FALSE
	 */
	public function post($url, $form = NULL, Cookie $Cookie = NULL, $filename = NULL) {
		return $this->request(self::HTTP_METHOD_POST, $url, $form, $Cookie, $filename);
	}

	/**
	 * GET/POST 请求一个指定的 URL。
	 *
	 * @param string $url
	 * @param Form $form
	 * @param Cookie $Cookie
	 * @param string $filename
	 * @return Response|FALSE
	 */
	private function request($method, $url, $form, $Cookie, $filename, $depth = 0) {
		$url = $this->url($url);
		
		if ($url) {
			$mode = $this->option[self::OPT_MODE];
			
			if ($mode == self::OPT_MODE_DEFAULT) {
				if ($url['scheme'] == 'https') {
					$mode = self::OPT_MODE_CURL;
				} elseif ($form && $form->type != 'application/x-www-form-urlencoded') { // 上传文件
					$mode = self::OPT_MODE_SOCKET;
				} else {
					$mode = self::OPT_MODE_STREAM;
				}
			}
			
			if ($url['scheme'] == 'http') {
				// void
			} elseif ($url['scheme'] == 'https' && $mode == self::OPT_MODE_CURL) {
				// void
			} else {
				return FALSE;
			}
			
			switch ($mode) {
				case self::OPT_MODE_STREAM :
					return $this->stream($method, $url, $form, $Cookie, $filename, $depth);
				case self::OPT_MODE_CURL :
					return $this->curl($method, $url, $form, $Cookie, $filename, $this->option[self::OPT_REDIRECT] - $depth);
				case self::OPT_MODE_SOCKET :
					return $this->socket($method, $url, $form, $Cookie, $filename, $depth);
			}
		}
		
		return FALSE;
	}

	private function stream($method, $url, $form, $Cookie, $filename, $depth) {
		$uri = $url['scheme'] . '://' . $url['host'] . ($url['port'] ? ':' . $url['port'] : '') . $url['path'] . (isset($url['query']) ? '?' . $url['query'] : '');
		
		if ($method == self::HTTP_METHOD_GET && $form) {
			$uri .= (isset($url['query']) ? '&' : '?') . $form->rawurlencode();
		}
		
		// ==============================================================================
		// max_redirects 参数值为 1 将禁止自动 HTTP 重定向，因为重定向至的 URL 可能是 https，而 stream* 函数并不支持 https，所以改为手动重定向，当为 https 时，将改由 curl 库完成。
		$option = array('method' => $method, 'protocol_version' => '1.1', 'timeout' => $this->option[self::OPT_TIMEOUT], 'max_redirects' => 1, 'ignore_errors' => TRUE, 'header' => '', 'content' => '');
		
		foreach ($this->header as $key => $value) {
			$option['header'] .= "{$key}: {$value}\r\n";
		}
		
		$auth = $this->auth($url);
		$auth && $option['header'] .= 'Authorization: Basic ' . base64_encode($auth) . "\r\n";
		
		$setcookie = $Cookie ? $Cookie->package($url['path'], $url['host']) : '';
		$setcookie && $option['header'] .= "Cookie: {$setcookie}\r\n";
		
		if ($method == self::HTTP_METHOD_POST) {
			$option['content'] = is_object($form) ? $form->body() : '';
			
			$option['header'] .= 'Content-Type: ' . (is_object($form) ? $form->type : 'application/x-www-form-urlencoded') . "\r\n";
			$option['header'] .= 'Content-Length: ' . strlen($option['content']) . "\r\n";
		}
		
		// ==============================================================================
		$context = stream_context_create(array('http' => $option));
		
		$fp = fopen($uri, 'rb', NULL, $context);
		if (!$fp) {
			return FALSE;
		}
		
		$info = stream_get_meta_data($fp);
		
		$count = -1;
		$header = array();
		
		foreach ($info['wrapper_data'] as $v) {
			if (preg_match('/^HTTP\/\S+\s+\d+\s?/i', $v)) {
				$header[++$count] = '';
			}
			
			$header[$count] .= $v . "\r\n";
		}
		
		for ($i = 0; $i < $count; $i++) {
			$Response = new Response($header[$i], $Cookie);
			
			$this->_setcookie($url['host'], $url['path'], $Cookie, $Response);
		}
		
		if ($this->option[self::OPT_IGNORE_FAIL] && !preg_match('/^\s*HTTP\/\S+\s+(?:1|2|3)\d{2}\s/i', end($header))) {
			fclose($fp);
			
			return NULL;
		}
		
		// ==============================================================================
		$Response = new Response(end($header) . ($filename ? '' : "\r\n" . stream_get_contents($fp)), $Cookie);
		
		$this->_setcookie($url['host'], $url['path'], $Cookie, $Response);
		
		$status = $Response->status();
		$location = $Response->header('Location');
		
		if ($location && ($status == 301 || $status == 302) && $this->option[self::OPT_REDIRECT] > $depth) {
			fclose($fp);
			
			$location = preg_match('/^http:\/\/\w/i', $location) ? $location : 'http://' . $url['host'] . ($url['port'] ? ':' . $url['port'] : '') . ($location[0] == '/' ? $location : rtrim($url['path'], '/') . '/' . $location);
			
			return $this->request(self::HTTP_METHOD_GET, $location, NULL, $Cookie, $filename, ++$depth);
		}
		
		if ($filename) {
			if (file_put_contents($filename, $fp) === FALSE) {
				fclose($fp);
				
				return FALSE;
			}
			
			if ($Response->header('Content-Encoding') == 'gzip') {
				$cFilename = $filename . strtoupper(uniqid());
				
				$cFp = fopen($cFilename, 'wb');
				if (!$cFp) {
					fclose($fp);
					
					return FALSE;
				}
				
				$gFp = gzopen($filename, 'r');
				
				while (!gzeof($gFp)) {
					fputs($cFp, gzread($gFp, 1048576));
				}
				
				fclose($cFp);
				gzclose($gFp);
				
				unlink($filename) && rename($cFilename, $filename);
			}
		}
		
		fclose($fp);
		
		return $Response;
	}

	private function curl($method, $url, $form, $Cookie, $filename, $depth) {
		if (!function_exists('curl_init')) {
			trigger_error('CURL extension is not supported', E_USER_WARNING);
			
			return FALSE;
		}
		
		// ==============================================================================
		$uri = $url['scheme'] . '://' . $url['host'] . ($url['port'] ? ':' . $url['port'] : '') . $url['path'] . (isset($url['query']) ? '?' . $url['query'] : '');
		
		if ($method == self::HTTP_METHOD_GET && $form) {
			$uri .= (isset($url['query']) ? '&' : '?') . $form->rawurlencode();
		}
		
		// ==============================================================================
		$ch = curl_init();
		
		curl_setopt($ch, CURLOPT_URL, $uri);
		curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
		curl_setopt($ch, CURLOPT_ENCODING, $this->header['Accept-Encoding']);
		curl_setopt($ch, CURLOPT_TIMEOUT, $this->option[self::OPT_TIMEOUT]);
		
		isset($url['port']) && curl_setopt($ch, CURLOPT_PORT, $url['port']);
		
		if ($url['scheme'] == 'https') {
			curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 1);
			curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE);
		}
		
		if ($depth > 0) {
			curl_setopt($ch, CURLOPT_MAXREDIRS, $depth);
			curl_setopt($ch, CURLOPT_FOLLOWLOCATION, TRUE);
		}
		
		$auth = $this->auth($url);
		$auth && curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
		$auth && curl_setopt($ch, CURLOPT_USERPWD, $auth);
		
		$setcookie = $Cookie ? $Cookie->package($url['path'], $url['host']) : '';
		$setcookie && curl_setopt($ch, CURLOPT_COOKIE, $setcookie);
		
		// ==============================================================================
		$header = array();
		
		foreach ($this->header as $key => $value) {
			$header[] = "$key: $value";
		}
		
		if ($method == self::HTTP_METHOD_POST) {
			$body = is_object($form) ? $form->body() : '';
			
			$header[] = 'Content-Type: ' . (is_object($form) ? $form->type : 'application/x-www-form-urlencoded');
			$header[] = 'Content-Length: ' . strlen($body);
			
			curl_setopt($ch, CURLOPT_POST, TRUE);
			curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
		}
		
		curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
		
		// 若响应数据直接写入文件，则分别设置响应头和响应体的文件句柄。
		// ==============================================================================
		if ($filename) {
			$hDir = HHO::$config['runtime.root'] . '/Lib/Http/Request';
			$hFile = $hDir . '/H-' . strtoupper(uniqid());
			
			is_dir($hDir) || mkdir($hDir, NULL, TRUE);
			
			$hFp = fopen($hFile, 'w');
			if (!$hFp) {
				curl_close($ch);
				
				return FALSE;
			}
			
			$bFp = fopen($filename, 'wb');
			if (!$bFp) {
				fclose($hFp);
				
				curl_close($ch);
				
				return FALSE;
			}
			
			curl_setopt($ch, CURLOPT_WRITEHEADER, $hFp);
			curl_setopt($ch, CURLOPT_FILE, $bFp);
		} else {
			curl_setopt($ch, CURLOPT_HEADER, TRUE);
			curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
		}
		
		// ==============================================================================
		$result = curl_exec($ch);
		
		if ($filename) {
			fclose($hFp);
			fclose($bFp);
			
			$message = file_get_contents($hFile);
			
			unlink($hFile);
		} else {
			$message = &$result;
		}
		
		if (!$result) {
			curl_close($ch);
			
			return FALSE;
		}
		
		curl_close($ch);
		
		if ($this->option[self::OPT_IGNORE_FAIL] && !preg_match('/^\s*HTTP\/\S+\s+(?:1|2|3)\d{2}\s/i', $message)) {
			is_file($filename) && unlink($filename);
			
			return NULL;
		}
		
		// CURL 将多次 Location 响应一次返回，需要拆分处理。
		// ==============================================================================
		$count = -1;
		$header = array();
		
		while (TRUE) {
			$header[++$count] = strstr($message, "\r\n\r\n", TRUE);
			
			$message = substr($message, strlen($header[$count]) + 4);
			
			if (!preg_match('/^HTTP\/\S+\s+\d+\s?/i', $message)) {
				break;
			}
		}
		
		for ($i = 0; $i < $count; $i++) {
			$Response = new Response($header[$i], $Cookie);
			
			$this->_setcookie($url['host'], $url['path'], $Cookie, $Response);
		}
		
		$Response = new Response(end($header) . "\r\n\r\n" . $message, $Cookie);
		
		$this->_setcookie($url['host'], $url['path'], $Cookie, $Response);
		
		return $Response;
	}

	private function socket($method, $url, $form, $Cookie, $filename, $depth) {
		$fp = fsockopen($url['host'], $url['port'] ?  : 80, $errorno, $errorstr, $this->option[self::OPT_TIMEOUT]);
		if (!$fp) {
			return FALSE;
		}
		
		$this->option[self::OPT_READ_TIMEOUT] > 0 && socket_set_timeout($fp, $this->option[self::OPT_READ_TIMEOUT]);
		
		// ==============================================================================
		$header = $method . ' ' . $url['path'] . (isset($url['query']) ? '?' . $url['query'] : '');
		
		if ($method == self::HTTP_METHOD_GET && $form) {
			$header .= (isset($url['query']) ? '&' : '?') . $form->rawurlencode();
		}
		
		$header .= " HTTP/1.1\r\n";
		
		isset($this->header['Host']) || ($header .= "Host: {$url['host']}" . ($url['port'] ? ":{$url['port']}" : '') . "\r\n");
		
		foreach ($this->header as $key => $value) {
			$header .= "{$key}: {$value}\r\n";
		}
		
		$auth = $this->auth($url);
		$auth && $header .= 'Authorization: Basic ' . base64_encode($auth) . "\r\n";
		
		$setcookie = $Cookie ? $Cookie->package($url['path'], $url['host']) : '';
		if ($setcookie) {
			$header .= "Cookie: {$setcookie}\r\n";
		}
		
		// ==============================================================================
		$body = array();
		
		if ($method == self::HTTP_METHOD_POST) {
			$size = 0;
			
			$body = is_object($form) ? $form->body(FALSE) : array();
			
			foreach ($body as &$v) {
				$size += $v['size'];
			}
			
			$header .= 'Content-Type: ' . (is_object($form) ? $form->type : 'application/x-www-form-urlencoded') . "\r\n";
			$header .= 'Content-Length: ' . $size . "\r\n";
		}
		
		// ==============================================================================
		fputs($fp, $header . "\r\n");
		
		foreach ($body as &$value) {
			fputs($fp, $value['data']);
			
			if (isset($value['files'])) {
				$lFp = fopen($value['files'], 'rb');
				
				if (!$lFp) {
					fclose($fp);
					
					return FALSE;
				}
				
				while (!feof($lFp)) {
					fputs($fp, fread($lFp, 1048576));
				}
				
				fputs($fp, "\r\n");
				
				fclose($lFp);
			}
		}
		
		// ==============================================================================
		$data = '';
		
		while (TRUE) {
			$block = fgets($fp);
			
			if (!$block) {
				fclose($fp);
				
				return FALSE;
			}
			
			if ($block == "\r\n") {
				break;
			} else {
				$data .= $block;
			}
		}
		
		if ($this->option[self::OPT_IGNORE_FAIL] && !preg_match('/^\s*HTTP\/\S+\s+(?:1|2|3)\d{2}\s/i', $data)) {
			fclose($fp);
			
			return NULL;
		}
		
		if (!$filename) {
			$data .= "\r\n";
			
			if (preg_match('/Transfer\-Encoding\s*:\s*chunked\s/i', $data)) {
				$chunk = hexdec(rtrim(fgets($fp)));
				
				while ($chunk) {
					while ($chunk) {
						$data .= $d = fread($fp, $chunk);
						
						if (!$data) {
							fclose($fp);
							
							return FALSE;
						}
						
						$chunk -= strlen($d);
					}
					
					fgets($fp);
					
					$chunk = hexdec(rtrim(fgets($fp)));
				}
			} else {
				$data .= stream_get_contents($fp);
			}
		}
		
		// ==============================================================================
		$Response = new Response($data, $Cookie);
		
		$this->_setcookie($url['host'], $url['path'], $Cookie, $Response);
		
		$status = $Response->status();
		$location = $Response->header('Location');
		
		if ($location && ($status == 301 || $status == 302) && $this->option[self::OPT_REDIRECT] > $depth) {
			fclose($fp);
			
			if ($this->option[self::OPT_REDIRECT] > $depth) {
				$location = preg_match('/^http:\/\/\w/i', $location) ? $location : 'http://' . $url['host'] . ($url['port'] ? ':' . $url['port'] : '') . ($location[0] == '/' ? $location : rtrim($url['path'], '/') . '/' . $location);
				
				return $this->request(self::HTTP_METHOD_GET, $location, NULL, $Cookie, $filename, ++$depth);
			}
		}
		
		if ($filename) {
			if ($Response->header('Transfer-Encoding') == 'chunked') {
				$tFp = fopen($filename, 'wb');
				
				$chunk = hexdec(rtrim(fgets($fp)));
				
				while ($chunk) {
					while ($chunk) {
						$d = fread($fp, $chunk > 1048576 ? 1048576 : $chunk);
						
						if (!$d) {
							fclose($fp);
							
							fclose($tFp);
							
							unlink($filename);
							
							return FALSE;
						}
						
						fputs($tFp, $d);
						
						$chunk -= strlen($d);
					}
					
					fgets($fp);
					
					$chunk = hexdec(rtrim(fgets($fp)));
				}
				
				fclose($tFp);
			} elseif (file_put_contents($filename, $fp) === FALSE) {
				fclose($fp);
				
				return FALSE;
			}
			
			if ($Response->header('Content-Encoding') == 'gzip') {
				$cFilename = $filename . strtoupper(uniqid());
				
				$cFp = fopen($cFilename, 'wb');
				if (!$cFp) {
					fclose($fp);
					
					return FALSE;
				}
				
				$gFp = gzopen($filename, 'r');
				
				while (!gzeof($gFp)) {
					fputs($cFp, gzread($gFp, 1048576));
				}
				
				fclose($cFp);
				gzclose($gFp);
				
				unlink($filename) && rename($cFilename, $filename);
			}
		}
		
		fclose($fp);
		
		return $Response;
	}
}